Typy, wartości i referencje
Zgodnie z planem, zaczniemy od poznania idei referencji. Czym one w ogóle są? Do czego służą? Najłatwiej zrozumiesz to na praktycznym przykładzie. Wyobraź sobie następującą sytuację:
let personOne = 'John';
let personTwo = personOne;
personTwo = personOne + ' II';
console.log('Person one', personOne);
console.log('Person two', personTwo);
Jak myślisz, jaka będzie końcowa wartość zmiennych personOne i personTwo?
Zapewne łatwo odpowiesz na to pytanie. W pierwszej linijce deklarujesz zmienną personOne o wartości John. Następnie tworzysz zmienną personTwo, która ma być kopią personOne. Na końcu modyfikujesz tę kopię (a więc personTwo), dodając jej tekst II. Tym samym na końcu te zmienne powinny mieć następujące wartości: John oraz John II.
Sprawdźmy:
Przypuszczamy, że nie pojawiły się tutaj u Ciebie żadne wątpliwości. W takim razie czas na kolejny przykład:
const personOne = { firstName: 'John', lastName: 'Doe' };
const personTwo = personOne;
personTwo.firstName = 'Amanda';
console.log('Person one', personOne);
console.log('Person two', personTwo);
Jak myślisz, co konsola pokaże tym razem po wykonaniu wszystkich instrukcji?
Wydaje się, że całość jest bardzo podobna do pierwszego przykładu. Tworzymy obiekt, następnie kopiujemy go do nowej stałej. Potem modyfikujemy kopie i pokazujemy zawartość obu obiektów. Wydaje się więc, że pierwszy powinien pozostać niezmieniony, ale drugi wyglądać tak: { firstName: 'Amanda', lastName: 'Doe' }. Jak myślisz, czy tak właśnie będzie?
Jeśli uważasz, że nie, to masz rację.
Jak widzisz, zamiast dwóch różnych obiektów, otrzymaliśmy dwa takie same. Oba mają atrybut firstName o wartości Amanda. Jak do tego doszło? Odpowiedź będzie dość krótka.
Jak udało nam się już wcześniej udowodnić, w przypadku prostych typów danych (tzw. prymitywów) JS, przy próbie przypisania, kopiuje wartość. Dzieje się tak więc ze stringami (jak na przykładzie pierwszym), ale tak samo JS zachowałby się w przypadku liczb, wartości boolean oraz innych typów prostych.
Poniżej przykład z innym “prymitywem”, czyli typem number.
let personAgeOne = 10;
let personAgeTwo = personAgeOne;
personAgeTwo += 2;
console.log('Person one', personAgeOne);
console.log('Person two', personAgeTwo);
Złożone typy danych będą już jednak zachowywały się inaczej. W przypadku typów złożonych (np. obiekty, tablice, funkcje) JS stosuje mechanizm referencji. Na czym on polega? Wartości złożonych typów danych z reguły są dość duże. Oczywiście w jednym przypadku mogą być ogromne, a w innym znacznie mniejsze, ale co do zasady – są znacznie bardziej “pamięciożerne” niż wartości typów prostych. Dlatego też JS, zamiast bezmyślnie je kopiować, w przypadku przypisania do nowej zmiennej czy stałej, przekazuje im tylko “adres” do oryginalnego obiektu. Zatem nie tworzymy kopii, lecz ta nowa zmienna/stała staje się tylko czymś w rodzaju “linku” (referencją) kierującego do oryginału.
Dlaczego JS tak postępuje? Ze względu na wydajność. Po co trzymać w pamięci dwa lub więcej takich samych obiektów? Przecież tylko niepotrzebnie zajmowałyby tam miejsce.
Mając taką wiedzę, wróćmy na chwilę do naszego przykładu numer dwa:
const personOne = { firstName: 'John', lastName: 'Doe' };
const personTwo = personOne;
personTwo.firstName = 'Amanda';
console.log('Person one', personOne);
console.log('Person two', personTwo);
Teraz już wiemy co tu się naprawdę dzieje.
const personOne = { firstName: 'John', lastName: 'Doe' };
Najpierw JS tworzy nową stałą o nazwie personOne i przypisuje do niej adres (referencję) do nowo utworzonego obiektu w pamięci – { firstName: 'John', lastName: 'Doe' }. Dlaczego adres, a nie kopię zawartości? To już wiemy, bo w przypadku typów złożonych (a obiekt takim jest) JS domyślnie przypisuje zamiast kopii tylko adres (referencję). personOne tym samym od początku jest tylko i wyłącznie odnośnikiem do miejsca w pamięci, gdzie jest przechowywany nasz nowy obiekt.
Następnie tworzona jest nowa stała personTwo, która jako wartość również otrzymuje adres (referencję) do personOne. Tak naprawdę można powiedzieć, że personTwo od samego początku nie jest “własnym bytem”. Zamiast tego od razu również staje się tylko odnośnikiem (referencją) do tego samego obiektu co personOne.
W kolejnym kroku staramy się zmienić wartość właściwości firstName obiektu personTwo. Tylko że czym w tej chwili jest tak naprawdę ta stała? Adresem do tego samego obiektu co personOne. Dlatego też, próbując dostać się do personTwo.firstName, tak naprawdę dostajemy się do tego samego miejsca, do którego pokierowałoby nas również personOne.firstName. Czyli modyfikując personTwo.firstName, zmieniamy tak naprawdę tylko jeden pierwotny obiekt, do którego kierują zarówno personOne, jak i personTwo.
console.log('Person one', personOne);
Pod koniec pokazujemy w konsoli wartość obu stałych. console.log('Person one', personOne) skieruje nas za pomocą referencji bezpośrednio do pierwotnego obiektu, a więc konsola pokaże nam obiekt { firstName: 'Amanda', lastName: 'Doe' }.
console.log('Person two', personTwo);
Ta linijka kodu powinna nam pokazać wartość personTwo. Jednak czym jest personTwo? Odnośnikiem do tego samego obiektu w pamięci co personOne, a więc w konsoli zobaczymy dokładnie to samo, co wyżej – wartość zmodyfikowanego obiektu pierwotnego.
Podsumowując, tak naprawdę od samego początku personOne i personTwo kierowały nas do tego samego obiektu w pamięci. Nie ważne więc czy próbowaliśmy modyfikować personOne czy personTwo, modyfikowaliśmy jeden i ten sam obiekt.
Możemy podsumować dotychczasową wiedzę następująco: przy próbie przypisania do zmiennej/stałej wartości typu prostego, JS przypisze jej kopię, a w przypadku typu złożonego – jej referencję.
Dla utrwalenia zróbmy jeszcze jeden mały przykład:
const namesOne = ['John', 'Amanda'];
const namesTwo = namesOne;
namesTwo.push('Thomas');
console.log(namesOne);
console.log(namesTwo);
Jaki będzie wynik działania powyższej funkcji? Co pojawi się w konsoli? Zastanów się na spokojnie.
Pokaż odpowiedź
Ukryj odpowiedź
W konsoli wyświetli się dwa razy ta sama zmodyfikowana tablica ['John', 'Amanda, 'Thomas'].
const namesOne = ['John', 'Amanda'];
W pierwszej linijce JS tworzy nowy obiekt w pamięci (tablicę ['John', 'Amanda']) i przypisuje jego referencję do nowej stałej namesOne. Dlaczego przypisuje ten obiekt w formie referencji, a nie kopii? Bo obiekt to typ złożony, a przy tych JS, jak pisaliśmy już wcześniej, korzysta właśnie z mechanizmu referencji.
const namesTwo = namesOne;
Następnie tworzymy stałą namesTwo i staramy się przypisać jej wartość namesOne. Stała namesOne jest jednak tylko referencją do obiektu pierwotnego, dlatego namesTwo również staje się referencją (adresem) do tej samej tablicy.
Zauważ więc, że od tej chwili mamy dwie stałe, ale obie kierują tak naprawdę do tego samego obiektu w pamięci, naszej tablicy z imionami.
namesTwo.push('Thomas');
W kolejnym kroku staramy się dodać do namesTwo nowy element. namesTwo jest tylko referencją do naszej tablicy imion w pamięci (podobnie, jak namesOne), więc push jest wykonywane właśnie na niej. Nasza tablica zapisana w pamięci otrzymuje na tym etapie nowy element Thomas.
console.log(namesOne);
console.log(namesTwo);
Na końcu pokazujemy wartość obu stałych. Obie kierują pod ten sam obiekt w pamięci, więc pokażą dokładnie to samo – tablicę z trzema elementami.
Jeśli na tym etapie wszystko jest dla Ciebie jasne, to możemy iść dalej. Jeśli jednak nie czujesz się zbyt pewnie, to postaraj się przerobić oba przykłady jeszcze raz. Na spokojnie.
Referencje, kopie i funkcje
Jak wygląda sytuacja z funkcjami? Czy przekazując jakieś dane w formie parametru, też przekazujemy – zależnie od typu – kopię albo referencję? Jak najbardziej!
Pamiętaj, że ustawienie wartości parametru, koniec końców, nie różni się zbytnio od procesu zwykłej deklaracji zmiennej. Pozwala ono po prostu na przypisanie do zakresu funkcji jakiejś zmiennej o nazwie równej nazwie parametru i wartości równej przekazanemu argumentowi. Zatem skoro tutaj też dochodzi do przypisywania wartości, to musimy trzymać się tych samych zasad co wcześniej.
const label = 'Names of people';
const names = ['John', 'Amanda'];
function prepareAndShowNames(namesArr, title) {
title = '==' + title + '==';
namesArr.push('Thomas');
console.log(title, namesArr);
}
prepareAndShowNames(names, label);
Nie ma tu niczego specjalnie skomplikowanego, ale żeby nie było żadnych wątpliwości, powiedzmy najpierw, co dokładnie ma tu robić funkcja prepareAndShowNames. Powinna ona otrzymywać tablicę imion, a także tytuł. Zadaniem tej funkcji jest dodanie do tablicy nowego imienia (Thomas), a następnie pokazanie jej zawartości zaraz po tytule.
Postaraj się uruchomić ten kod, np. na CodePenie. Jeśli to zrobisz, to od razu rzucą Ci się w oczy dwie rzeczy.
Po pierwsze okazuje się, że JavaScript nie ma problemu z tym, że staramy się przypisywać coś do parametru. JS spokojnie poradził sobie z linijką title = '==' + title + '=='. Potwierdza się tylko to, o czym pisaliśmy wcześniej. Ustalenie parametru funkcji kończy się, tak czy inaczej, zadeklarowaniem zmiennej (w tej sytuacji o nazwie title), dlatego też nie otrzymujemy w konsoli żadnego komunikatu, jakoby title była niezadeklarowana. Nie widzimy tego wprost, ale "pod maską" JS naprawdę deklaruje parametr jako zwykłą zmienną i tak ją potem traktuje. Dlatego też bez problemu przypisuje do niej nową wartość, bo w istocie była zadeklarowana – właśnie na etapie inicjacji wykonania funkcji.
Po drugie, okazuje się, że po wykonaniu funkcji zmodyfikowana została nie tylko tablica namesArr, wewnątrz której pracowaliśmy, ale również przekazywany w formie parametru oryginał – tablica dostępna pod stałą names. Za to label pozostał bez zmian. Dlaczego?
Jeśli wiemy już, że w przypadku przekazywania danych do funkcji stosujemy te same zasady co przy najprostszym przypisywaniu, to możemy łatwo rozszyfrować, co tu się właściwie stało.
const label = 'Names of people';
Zacznijmy od pierwszej linijki. Tworzymy nową stałą i przypisujemy do niej kopię wartości tekstowej stringa. Dlaczego kopię, a nie referencję? To już wiemy. W przypadku przypisywania wartości typów prostych JS kopiuje wartość.
Zatem możemy powiedzieć, że stała label faktycznie ma wartość tekstową Names of people.
const names = ['John', 'Amanda'];
Następnie staramy się stworzyć drugą stałą, tym razem o nazwie names. Tutaj mamy już jednak próbę przypisania obiektu, więc JS skorzysta z mechanizmu referencji. Co tu się właściwie stanie?
JS utworzy w pamięci nowy obiekt – tablicę ['John', 'Amanda'], a adres (referencja) do niej zostanie przypisany do nowoutworzonej stałej names. Krótko mówiąc, od tego momentu names jest tylko i wyłącznie odnośnikiem do właśnie utworzonego obiektu w pamięci – naszej tablicy ['John', 'Amanda'].
prepareAndShowNames(names, label);
Zapewne zdajesz sobie sprawę, że JS po zauważeniu deklaracji funkcji od razu jej nie wykona. Możemy więc założyć, że zapisze ją w pamięci, ale nie uruchomi, tylko pójdzie dalej. I dopiero teraz, już w tej konkretnej powyższej linijce, w końcu tę funkcję włączy.
Od czego zacznie JS przy jej wykonywaniu? Od zadeklarowania zmiennych z parametrów. Do namesArr postara się przypisać wartość stałej names, a do title wartość stałej label. Zauważ, że faktycznie dochodzi tu do próby przypisania.
Teraz, znając już zasady, możemy łatwo ustalić, jakie wartości przyjmą nasze parametry. Skoro names odnosi się swoją referencją do obiektu złożonego (tablicy), to do namesArr przypiszemy również po prostu referencję do miejsca w pamięci, gdzie ten obiekt się znajduje. W przypadku title mamy typ prosty, możemy więc założyć, że w tej sytuacji JS po prostu skopiuje do title wartość stałej label.
Zatem na tym etapie wiemy już, że przy wywołaniu prepareAndShowNames w funkcji tej pojawiają się dwie zmienne: namesArr, która jest referencją do tego samego obiektu w pamięci co names, czyli również kieruje nas do tablicy ['John', 'Amanda'], oraz title, której wartość będzie równa tekstowi Names of people.
title = '==' + title + '==';
W następnej linijce staramy się tylko lekko udekorować otrzymany tytuł, np. Names zostanie zamienione na ==Names of people==.
Stała czy zmienna?
Na tym etapie może pojawić się w Twojej głowie jedno pytanie. Mówimy, że ustalając parametry w funkcji, tak naprawdę deklarujemy dodatkowe zmienne w jej zakresie. Tylko czy na pewno zmienne, a może stałe? Skąd mamy to wiedzieć? Najlepiej spojrzeć na nasz mały eksperyment.
Zauważ, że linijka kodu title = '==' + title + '=='; nie spowodowała błędu. To dowodzi, że title (czyli drugi parametr) jest deklarowany jako zmienna. Gdyby był stałą, to nie można by było zmodyfikować jego wartości, a konsola pokazałaby błąd.
W takim razie idźmy dalej.
namesArr.push('Thomas');
W kolejnym kroku JS postara się dodać do namesArr nowy element. Co istotne namesArr, jak już wiemy, jest tylko referencją do naszej tablicy z imionami, którą przechowujemy w pamięci. Tym samym, próbując dodać coś do namesArr, za każdym razem odnosimy się do tej samej tablicy w pamięci, do której prowadzi również stała names. Jednej i tej samej tablicy.
console.log(title, namesArr)
Kolejną linijkę na pewno jesteś w stanie łatwo rozszyfrować. Staramy się pokazać wartość title i namesArr. title to zwykły tekst ==Names of people== i on zostanie pokazany jako pierwszy. namesArr kieruje za to pod adres w pamięci, gdzie jest nasza zmodyfikowana przed chwilą tablica z imionami. Zatem jako druga, zostanie pokazana właśnie ona.
console.log(label, names);
Po wykonaniu funkcji pozostaje nam jeszcze jedna instrukcja. Jako label konsola pokaże wartość tekstową stałej label, czyli wciąż Names. Jak wiemy, do funkcji była przekazywana tylko kopia label, więc żadne modyfikacje poczynione w funkcji na kopii nie wpłynęły na oryginał. Za to już jako names JS pokaże nam dokładnie to samo, co przed chwilą widzieliśmy jako namesArr. Dlaczego? Bo i names i namesArr prowadzą tak naprawdę do tego samego jednego elementu.
Kopiowanie złożonych danych
Coraz lepiej orientujesz się w temacie. Powstaje jednak jedno pytanie. Czy JS zawsze zamiast kopiować złożone dane, przekazuje tylko referencje do nich? Czy da się go jakoś zmusić, żeby zachował się inaczej? W końcu w naszym przykładzie wyżej na pewno wolelibyśmy otrzymać kopię danych, do których prowadzi names. Dzięki temu namesArr mogłoby być w naszej funkcji dowolnie modyfikowane, bez obaw, że "zepsujemy" coś w oryginale.
Odpowiedź jest prosta – da się. Nie będziemy jednak póki co zaprzątać sobie tym głowy. Na razie wystarczy, że wiesz, iż taka opcja istnieje. A jaka dokładnie? Opowiemy o tym trochę później, kiedy faktycznie będziemy musieli skorzystać z tej wiedzy w praktyce.
Ćwiczenia
Czas podsumować nową dawkę wiedzy krótkim quizem.
Pytanie 1
Przy próbie przypisywania wartości, JS – zależnie do typu – kopiuje wartość lub przekazuje referencję (adres). Od czego to zależy?
Pokaż odpowiedź
Ukryj odpowiedź
Od typu. Wartości typów złożonych (czyli np. tablice, obiekty, funkcje) są przekazywane przez referencję, a prostych (liczby, tekst itd.) jako kopia.
Pytanie 2
W jaki sposób są przekazywane dane do funkcji? W formie kopii czy referencji?
Pokaż odpowiedź
Ukryj odpowiedź
To zależy od typu danych, które przekazujemy. Wartości typów złożonych (czyli np. tablice, obiekty, funkcje) są przekazywane przez referencję, a prostych (liczby, tekst itd.) jako kopia.
Pytanie 3
const namesOne = ['John', 'Amanda'];
let namesTwo = namesOne;
namesTwo.push('Thomas');
namesTwo = [];
console.log(namesOne);
console.log(namesTwo);
Jakie będą końcowe wartości namesOne i namesTwo?
Pokaż odpowiedź
Ukryj odpowiedź
To trochę podchwytliwe pytanie.
namesOne będzie referencją do tablicy o wartości ['John', 'Amanda', 'Thomas'].
namesTwo będzie referencją do pustej tablicy.
Zauważ, że w instrukcji namesTwo = [] mówimy JS-owi: utwórz nową tablicę i przypisz referencję do niej jako wartość namesTwo. A to oznacza, że od tej chwili namesTwo przestaje być odnośnikiem do pierwszej tablicy, a staje się odnośnikiem do drugiej – tej nowej i pustej. namesOne i namesTwo prowadzą więc od tego momentu do innych tablic.
Pytanie 4
const person = { firstName: '', lastName: '' };
const name = person.firstName;
const personOne = person;
personOne.firstName = 'John';
personOne.lastName = 'Doe';
const personTwo = person;
personTwo.firstName = 'Amanda';
personTwo.lastName = 'Doe';
console.log(name, personOne, personTwo);
Co pokaże się w konsoli?
Pokaż odpowiedź
Ukryj odpowiedź
'',
{ firstName: 'Amanda', lastName: 'Doe' },
{ firstName: 'Amanda', lastName: 'Doe' }
W przypadku personOne i personTwo zapewne nie masz wątpliwości. person, personOne i personTwo są referencją do dokładnie tego samego obiektu w pamięci. Nie ważne więc, gdzie robisz zmiany, modyfikujesz ten sam jeden obiekt.
const name = person.firstName;
Ta linijka mogła Cię zmylić, ale pamiętaj, że person.firstName to zwykły string (typ prosty), a jako taki został przypisany do name jako kopia. Tym samym, żadne dalsze zmiany w obiekcie, do którego kierował person nas nie interesują. Jego wartość pozostanie do końca taka sama (pusty string).
Pytanie 5
Czy istnieje opcja skopiowania obiektu?
Pokaż odpowiedź
Ukryj odpowiedź
Funkcje callback
Dość szczególnym wykorzystaniem mechanizmu referencji są funkcję callback. Korzystaliśmy już z nich w poprzednim module. Chociażby wtedy, kiedy przekazywaliśmy referencję do naszych funkcji, jako parametr metody addEventListener.
Np.
link.addEventListener('click', tagClickHandler);
Idea jest tutaj dość prosta. Przekazujemy jako jeden z argumentów funkcji inną funkcję i ta jest wywoływana wtedy, kiedy pierwsza z nich uzna, że jest taka potrzeba, np. kiedy funkcja pierwsza skończy jakiś proces.
Spójrz tylko na przykład:
function hello(name) {
console.log('Hey', name);
}
function runOtherFunc(callback) {
const val = prompt('Pass the value!');
callback(val);
}
runOtherFunc(hello);
Jak zadziała powyższy kod?
Na samym początku zostaną zadeklarowane dwie funkcje – hello i runOtherFunc. Oczywiście wiesz już, że na tym etapie nie zostaną one automatycznie uruchomione, a zaledwie zapisane w pamięci.
runOtherFunc(hello);
Dopiero ostatnia linijka faktycznie uruchamia jedną z nich, funkcję runOtherFunc. Spójrz dokładnie na to, co jest przekazywane jako parametr callback tej funkcji.
Jako parametr przekazujemy kolejną funkcję! A dokładnie referencję do funkcji dostępnej pod hello. Oznacza to, że po wywołaniu, funkcja runOtherFunc od razu zadeklaruje w swoim scope (zakresie) zmienną callback o wartości… no właśnie o wartości czego? Jak już wiesz, w tym momencie dochodzi do próby przypisania wartości do zmiennej. Wiemy też, że JS zależnie od sytuacji przekazuje kopię albo referencję – zależy to od typu danych, tego, czy jest on złożony, czy prosty. Jaki jest on w tej sytuacji? Funkcja to ewidentnie typ złożony, tym samym do funkcji runOtherFunc nie trafi kopia funkcji hello, lecz tylko referencja (adres) do niej. Możemy skrócić to do następującego stwierdzenia: callback i hello kierują tak naprawdę do tej samej funkcji — tego samego miejsca w pamięci. To bardzo istotne, bo dzięki temu wiemy, że wywołując potem w kodzie callback, tak naprawdę uruchamiamy po prostu tę samą funkcję, którą uruchomiłoby wywołanie hello.
Po ustaleniu zawartości callback, funkcja przechodzi do zapytania o wartość, którą ma przypisać do stałej val. Naturalnie może to być dowolny string. Funkcja w żaden sposób nie waliduje tego, co dostanie. Zamiast tego przechodzi po prostu dalej.
I tu dochodzimy do najciekawszego fragmentu tego kodu. Uruchamiamy callback z parametrem, przy czym jego wartość ma być równa temu, co wpisano właśnie przed chwilą do val.
Wiemy już do czego prowadzi callback – do funkcji, która jest też przypisana pod hello. Wywołując callback, uruchamiamy więc tak naprawdę tę funkcję:
function (name) {
console.log('Hey', name);
}
Wiemy też, że wywołując ją, jako pierwszy parametr (name) przekazujemy do niej wartość val (callback(val)). Tym samym, wpisując w pole wygenerowane przez prompta np. wartość John, możemy oczekiwać, że efektem działania programu będzie wyświetlanie w konsoli tekstu Hey John. Możesz to łatwo przetestować.
Jakie jest zastosowanie funkcji callback? Bardzo szerokie. Jej idea to jeden z podstawowych konceptów języka. Jest wykorzystywana m.in. w wielu wbudowanych w JS-a metodach jak addEventListener, map czy reduce (tych dwóch ostatnich jeszcze nie używaliśmy). Dobrze sprawdza się również w przypadku funkcji asynchronicznych, czyli takich, które potrafią wykonywać coś "w tle", niezależnie od wątku głównego. Często wykorzystuje się wtedy callback, jako referencję do funkcji, która ma być wywołana dopiero w momencie zakończenia asynchronicznej operacji. O tego typu funkcjach powiemy jednak trochę dalej. W tym module jeszcze się tym nie zainteresujemy.
Na co warto uważać?
W przypadku wykorzystywania funkcji callback musimy jednak uważać na jedną rzecz. Pamiętaj, aby przekazywać referencję do funkcji, a nie to, co ona zwraca. Spójrz tylko na poniższy kod.
function hello(name) {
console.log('Hey', name);
}
function runOtherFunc(callback) {
const val = prompt('Pass the value!');
callback(val);
}
runOtherFunc(hello());
O ile wcześniejszy przykład, w którym przy hello nie pojawiły się nawiasy, zadziałał bezbłędnie, to tutaj mielibyśmy już problem. Dla JS-a dwa skierowane do siebie nawiasy są równoznaczne z rozkazem “wykonaj tę funkcję”. Tym samym funkcja zostanie wykonana, w naszej sytuacji zwróci wartość undefined (brak słowa kluczowego return jest równe return undefined) i to właśnie ona zostanie przekazane jako wartość parametru callback. Tym samym runOtherFunc będzie starało się wywołać… wartość undefined, a jak zapewne się domyślasz, to nie ma prawa się udać.
Czasem jednak chcemy przekazać funkcję, od razu informując, z jakim argumentem ma się ona wykonać. Zresztą, taka sytuacja zdarzyła nam się nawet w aplikacji z grą kamień, papier, nożyce. Mieliśmy nasłuchiwacz, który powinien włączać funkcję playGame i od razu przekazywać jej informację, co wybrał gracz. Właśnie poprzez argument. Co w takiej sytuacji zrobić? Z jakich technik możesz skorzystać? Najprościej możemy po prostu “opakować” wywołanie takiej funkcji w inną funkcję. Właśnie tak na pewno udało Ci się to rozwiązać również w module z grą, prawda?
function hello(name) {
console.log('Hey', name);
}
function runOtherFunc(callback) {
const val = prompt('Pass the value!');
callback();
}
runOtherFunc(function() { hello('John'); });
Spójrz tylko na powyższy przykład.
Tym razem jako callback przekazujemy referencję do zupełnie nowej prostej funkcji function { hello('John'); }. Co istotne, nie włączamy jej (nie ma tu nawiasów ()), przekazujemy tylko referencję. Zatem udało nam się załatwić pierwszy problem. callback będzie tutaj referencją do funkcji function() { hello('John'); }, a nie tylko wartością działania funkcji, jak to było wcześniej.
A co stanie się dalej, kiedy dojdzie do wykonania funkcji callback w runOtherFunc? Uruchamiając callback, wystartujemy tak naprawdę tę funkcję:
function() {
hello('John');
}
Co ona w takiej sytuacji zrobi? Jej kod jest bardzo prosty: włączy funkcję, do której kieruje właśnie hello! A jako parametr przekaże tekst 'John'! Czyli ostatecznie i tak włączy się funkcja ukryta pod hello, tak jak chcieliśmy od samego początku – i co ważne, jest ona uruchamiana z założonym parametrem.
Jak widzisz, faktycznie udało nam się przemycić wywołanie hello do funkcji runOtherFunc wraz z informacją o wartości parametru. Oczywiście potrzebowaliśmy tutaj konia trojańskiego (pośrednika) w postaci dodatkowej funkcji, ale… udało się!
Zapewne podobnie wyglądało to w Twojej aplikacji z grą:
rockBtn.addEventListener('click', function() { playGame(1); });
Mamy rację? ;) Czyli już wcześniej zdarzyło Ci się skorzystać z tej techniki. Tylko że teraz już wiesz, dlaczego była nam ona w ogóle potrzebna.
Tajemniczy argument w addEventListener
Przy okazji wyjaśniła się kolejna magiczna rzecz z poprzednich modułów, a mianowicie argument event w metodzie addEventListener. Zapewne pamiętasz, że niektóre funkcje w aplikacji z blogiem, oczekiwały na pewien tajemniczy argument event. Mówiliśmy, że jest to obiekt z informacjami o zdarzeniu, a jedną z jego metod jest preventDefault, a więc funkcja, która potrafi blokować domyślne zachowanie przeglądarki. Skąd jednak on pochodził? Tego nie powiedzieliśmy. A w końcu, przy samym dodawaniu nasłuchiwacza, nic o żadnym obiekcie event nie wspominamy.
Spójrz tylko na jedną z pętli z tamtego projektu.
for(let link of links) {
link.addEventListener('click', tagClickHandler);
}
Staramy się tutaj przejść po każdym linku z kolekcji linków (links) i dla każdego z nich dodajemy nasłuchiwacz. Określamy, że JS ma obserwować każdy z linków i oczekiwać na event (zdarzenie) kliknięcia. Jeśli je wykryje, musi uruchomić funkcję tagClickHandler. Nie ma tu jednak słowa o tym, że funkcja ta otrzyma jakieś informacje. Skąd się więc one biorą?
Cóż, skoro już wiesz, jak działają funkcję callback, to możesz się tego łatwo domyślić.
Najprawdopodobniej metoda addEventListener wygląda mniej więcej tak:
addEventListener: function(eventType, callback) {
const eventObj = { preventDefault: ..., target: ...}
callback(eventObj)
}
Jest to po prostu funkcja, która oczekuje na dwa argumenty – informacje o typie zdarzenia, który chcemy obserwować (eventType) oraz referencje do funkcji, która ma się uruchomić po jego wykryciu (callback). Oczywiście jako callback przekazujemy zawsze referencje do funkcji, to już wiesz. Z taką wiedzą, łatwo możemy zrozumieć, co dzieje się dalej.
Kiedy JS wykryje, że dane zdarzenie rzeczywiście ma miejsce w obserwowanym elemencie, uruchamia argument callback. Jest on tylko referencją do przekazanej wcześniej funkcji. Tym samym uruchamiając callback, tak naprawdę uruchamiamy oryginalną przekazaną do tego parametru funkcję. W naszym przykładzie wyżej tą funkcją był tagClickHandler. Włączając więc callback, metoda addEventListener włączyłaby tak naprawdę tagClickHandler.
Jeszcze ważniejsze jest jednak to, w jaki sposób ją wywołujemy. Zobacz, że ta funkcja nie tylko jest uruchamiana, ale dodatkowo otrzymuje jeszcze jakieś dane! Właśnie wspomniany wcześniej obiekt z informacjami o zdarzeniu! Teraz wystarczy, żeby taka funkcja faktycznie taki argument "odbierała".
function tagClickHandler(event) {
...
}
Jeśli to zrobi, to może potem z niego skorzystać, np. uruchamiając metodę preventDefault. Oczywiście, jeśli w funkcji, którą przekazujemy, nie przygotujemy żadnego parametru, nic się nie stanie. Metoda addEventListener i tak taką funkcję uruchomi. Owszem, przekazany obiekt ze zdarzeniem nie będzie odebrany, ale czy musi? Wcale nie. Podsumowując, metoda addEventListener zawsze uruchamia otrzymaną funkcję callback wraz z obiektem z informacjami o zdarzeniu i jeśli chcemy, to możemy je odebrać. Wystarczy, że nasza funkcja callback będzie oczekiwała na przynajmniej jeden argument. Jeśli będzie, to właśnie on zostanie obdarowany takim obiektem.
Ćwiczenia
Pytanie 1
Jak myślisz, czy funkcja może przyjąć jako parametry więcej niż jedną funkcję callback? Czy nazwy tych parametrów mogą być dowolne?
Pokaż odpowiedź
Ukryj odpowiedź
Jak najbardziej. Działa to tak samo, jak z każdym innym typem danych. Podobnie jak możemy przekazywać dwa, trzy albo i więcej stringów czy obiektów, tak samo możemy czynić to z funkcjami.
Np.
function foo(cbOne, cbTwo) {
cbOne();
cbTwo();
}
foo(function() { console.log('One!'); }, function() { console.log('Two!'); })
...pokaże w konsoli tekst One! oraz Two!.
Nazwy parametrów mogą być oczywiście dowolne, chociaż często dla ułatwienia czytelności kodu korzystamy z nazw callback lub cb.
Pytanie 2
Jak uważasz, czy funkcja przekazywana jako callback do metody addEventListener może odbierać obiekt z informacjami o zdarzeniu do argumentu o innej nazwie niż event?
Pokaż odpowiedź
Ukryj odpowiedź
Oczywiście. Metoda addEventListener uruchamia funkcję przekazaną jako callback zawsze z jednym argumentem. Przekazuje w nim obiekt z informacjami o zdarzeniu. Jeśli ta funkcja będzie posiadała przynajmniej jeden parametr, to właśnie on otrzyma ten obiekt, ale jego nazwa nie ma znaczenia. Może to być event, może to być e albo i nawet abc. Chociaż oczywiście te dwie pierwsze mają znacznie więcej sensu ;)
Jeśli masz jeszcze jakieś wątpliwości, to przypomnij sobie, jak działają funkcje.
Np.
function foo(name) {
console.log(name)
}
foo('bar');
foo('baz');
function foo(param) {
console.log(param)
}
foo('bar');
foo('baz');
Wywołując funkcję foo, nie przejmujemy się, jak nazywa się parametr, pod który przekażemy daną wartość. Może to być name, a może to być param. Ważna jest jedynie kolejność. Pierwszy parametr zawsze dostanie pierwszą wartość, drugi drugą itd. Jednak nazwa może być dowolna.
Stąd też funkcja callback zawsze dostanie pod pierwszym argumentem informacje o evencie. Nieważne, czy nazywa się on e, event czy abc.
Pytanie 3 (dla ambitnych)
Czy poniższy kod jest poprawny? Co pokaże się w konsoli po jego wykonaniu?
function foo(cb, text) {
cb(text);
}
function bar(textOne, textTwo) {
console.log(textOne, textTwo);
}
foo(function(txt) { bar(txt, 'World') }, 'Hello');
To znacznie trudniejszy przykład, spróbuj jednak go rozwikłać.
Pokaż odpowiedź
Ukryj odpowiedź
Kod jest poprawny. Konsola wyświetli napis Hello World. Napis Hello pochodzi z argumentu text, który bar dostaje po wywołaniu cb (czyli funkcji function(txt) { bar(txt, 'World') }). World jest za to dostarczany do bar bezpośrednio.
To bardziej zawiły przykład. Nie oczekujemy, że masz go rozwiązać ot tak. Spróbuj go przeanalizować kilka razy, a w razie wątpliwości poproś o pomoc Mentora. Jeśli uda Ci się go zrozumieć bez dodatkowej pomocy, naprawdę może rozpierać Cię duma.
Magiczne słowo this
W poprzednim module pojawiło się jeszcze jedno tajemnicze słowo – this. Jak zapewne pamiętasz, pojawiało się w funkcjach i najczęściej wskazywało na kliknięty element. Czy zawsze tak jest? Niestety nie. Skąd w takim razie mamy wiedzieć czym będzie this w danej sytuacji?
Wbrew pozorom, to nie będzie aż takie trudne. Możliwości, czym this będzie w danej sytuacji, nie ma wcale aż tak dużo i opierają się ona na kilku prostych zasadach. Znając je, będziesz w stanie zawsze bezboleśnie i z pewnością stwierdzić, czego się spodziewać.
Przedstawimy je za chwilę w odwrotnej kolejności – od najmniej ważnej do tej, która dla silnika JS będzie kluczowa. Każda kolejna zasada będzie miała więc większy priorytet.
Uwaga!
Wszystkie te zasady tyczą się ustalania this w funkcji. W kontekście globalnym (czyli poza jakąkolwiek funkcją) this będzie zawsze równe obiektowi globalnemu, czyli window.
Default rule – Window vs Undefined
Pierwsza z nich jest dość krótka: jeśli skrypt jest wykonany w strict mode, to this w funkcji przyjmuje wartość undefined. Jeśli nie, to przyjmuje wartość obiektu globalnego, czyli obiektu window.
Proste? Proste! Aby to uwiarygodnić, sprawdź dwa poniższe przykłady:
'use strict';
console.log(this);
function foo() {
console.log(this);
}
foo();
W kontekście globalnym (czyli poza jakąkolwiek funkcją) this to naturalnie obiekt window. W funkcji foo, zgodnie z treścią w ramce powyżej – również będzie to już undefined.
To teraz przykład bez use strict, żeby udowodnić drugą tezę:
console.log(this);
function foo() {
console.log(this);
}
foo();
Oczywiście this w kontekście globalnym się nie zmieniło. Tak jak mówiliśmy, jest to bowiem zawsze window. Zmienił się jednak this w funkcji foo, który teraz zgodnie z zasadą również wskazuje na window.
Jak widzisz, pierwsza zasada okazała się dość prosta. W takim razie możemy iść dalej.
Implicit binding rule – wywoływanie metody
Druga jest już trochę ciekawsza. Na razie tworzyliśmy głównie bardzo proste obiekty, takie jak np. allTags.
const allTags = {
code: 1,
news: 2,
...
}
Mogą być one jednak znacznie ciekawsze. Właściwości obiektu nie muszą być bowiem proste. Nie muszą być tylko tekstem, czy liczbą (jak w przykładzie wyżej). Mogą być również tablicą, kolejnym obiektem, czy nawet funkcją!
Spójrz tylko na poniższy przykład:
const JohnDoe = {
firstName: 'John',
lastName: 'Doe',
hobbies: ['sport', 'movies'],
sayHello: function() {
console.log('Hello!');
}
}
Dostęp do właściwości sayHello nie będzie inny niż w przypadku chociażby firstName. Dojdziemy do niej po "kropce".
JohnDoe.sayHello();
Kiedy już to wiemy, to możemy przejść do zasady numer dwa. Jeśli wywołujemy metodę (właściwość, która jest funkcją) jakiegoś obiektu, to wskazuje on właśnie na ten obiekt.
Dwa krótkie przykłady:
const foo = {
bar: function() {
console.log(this);
}
}
foo.bar();
Na co wskaże this? Zgodnie z tą zasadą, na obiekt foo. To jeszcze jeden:
function func() {
console.log(this);
}
const foo = {
bar: func
}
foo.bar();
Drugi przykład jest znacznie ciekawszy. Czym będzie this tym razem? Zgodnie z zasadą – również foo.
Metoda bar to tylko referencja do funkcji znanej jako func. Uruchamiając więc foo.bar, tak naprawdę uruchamiamy funkcję:
function() {
console.log(this);
}
A skoro uruchamiamy ją "na obiekcie" (foo.bar), no to this będzie wskazywać również właśnie na ten obiekt.
Pokazuje to jedną bardzo istotną rzecz: nieważne, gdzie funkcja jest zapisana w kodzie. Ważne gdzie jest wywoływana. Zauważ, że w naszym przykładzie funkcja bar jest tak naprawdę “trzymana” poza obiektem foo. Atrybut bar z tego obiektu jest tylko referencją do niej. Jednak mimo tego, że sama funkcja jest poza tym obiektem, to przy wywołaniu wskazuje właśnie na niego. Czyli jeszcze raz, zapamiętaj – this tyczy się miejsce wywołania funkcji (tzw. "call site"), a nie jej fizycznej pozycji w kodzie.
Żeby zostało Ci to w głowie, spójrz na ostatni przykład:
function func() {
console.log(this);
}
const obj1 = {
name: 'object 1',
bar: func
}
const obj2 = {
name: 'object 2',
bar: func
}
obj1.bar();
obj2.bar();
Czym będzie this w przypadku wywołania obj1, a czym w przypadku wywołania obj2? Zgodnie z zasadą – obiektem, na którym włączamy tę funkcję. Raz będzie to więc obj1, a za drugim razem obj2. Zatem widzisz, ponownie mimo tego, że kierujemy do jednej i tej samej funkcji i nie zmieniła ona położenia, to this zależnie od miejsca jej wywołania (call site), wskaże na inną wartość.
Explicit binding rule – wymuszenie kontekstu
W wielu przypadkach dwie pierwsze zasady mogą Ci wystarczyć, ale znajdą się wciąż takie, w których bez znajomości kolejnych, nie zrozumiemy, co się dzieje.
Spójrz chociażby na ten przykład:
const button = document.querySelector('#btn');
function foo(event) {
console.log(event, this);
}
button.addEventListener('click', foo);
Załóżmy, że stała button kieruje nas do faktycznie istniejącego przycisku nas stronie. Jak myślisz, co pokazałoby się w konsoli po kliknięciu?
Zapewne wiesz już z poprzednich modułów, że będzie to obiekt z informacjami o evencie (parametr event) oraz referencja do samego buttona (this). Dlaczego jednak w tej sytuacji this jest tym, czym jest? Dlaczego jest buttonem? W końcu żadne z naszych znanych zasad takiego przypadku nie omawiają. Jak widzisz, musimy drążyć dalej.
Aby zrozumieć ten przykład, musimy wrócić do omówienia, jak może być zbudowana funkcja addEventListener
addEventListener: function(eventType, callback) {
const eventObj = { preventDefault: ..., target: ...}
callback(eventObj)
}
Przypomnijmy, zapewne przyjmuje ona w formie parametrów informacje, na jakie zdarzenie JS ma zwrócić uwagę oraz referencję do funkcji callback, która ma się wykonać w momencie jego wykrycia.
W momencie wykrycia zdarzenia zapewne tworzony jest obiekt ze szczegółowymi informacjami na jego temat, a gdy jest już gotowy, dochodzi do wywołania funkcji callback wraz z przekazaniem tego obiektu poprzez pierwszy parametr.
Taki scenariusz tłumaczy skąd możliwość “odebrania” tego obiektu pod parametrem event w naszym przykładzie.
const button = document.querySelector('#btn');
function foo(event) {
console.log(event, this);
}
button.addEventListener('click', foo);
No dobrze, ale co z samym elementem this? Dlaczego wskazuje on na button? Spokojnie, zaraz do tego dojdziemy.
Na pewno addEventListener musi wiedzieć, na jaki element powinien zwracać uwagę, obserwować. Skąd? Tu odpowiedź będzie bardzo prosta.
Spójrz na wywołanie tej funkcji w naszym przykładzie:
button.addEventListener('click', foo);
Czy któraś z poznanych Ci już zasad się w tym miejscu sprawdzi? Tak. Zasada Implicit binding rule, czyli jeśli włączamy metodę na obiekcie, to this w kontekście wywołania takiej funkcji wskaże właśnie na ten obiekt. A więc na co wskaże this w kontekście wywołania addEventListener w naszym przypadku? Na przycisk (stała button)! Pamiętaj bowiem, że element DOM to obiekt jak każdy inny.
Dobrze, wiemy już wewnątrz funkcji addEventListener, że this wskaże tu nasz button, ale jak to możliwe, że potem ten this został przekazany dalej do funkcji foo? Jak rozwiązali to twórcy tej funkcji?
Na pewno nasz pomysł jest tutaj zbyt prosty.
addEventListener: function(eventType, callback) {
const targetElement = this;
console.log(targetElement);
const eventObj = { preventDefault: ..., target: ...}
callback(eventObj)
}
Wiemy, że wewnątrz funkcji addEventListener mamy dostęp do odpowiedniego this, ale czy wywołanie callback też go dostanie? Nie. Dla wywołania funkcji JS ustala this od nowa. Która ze znanych Ci już zasad byłaby więc brana pod uwagę przy jego ustaleniu dla funkcji callback? Nie jest to funkcja wywoływana na obiekcie, więc zgodnie z hierarchią trafiamy do zasady default rule (domyślnej).
Zakładając, że nie używamy w naszym przykładzie strict mode, zgodnie z zasadą callback (czyli foo) wskaże nam jako this obiekt globalny window.
Wszystko dotychczas jest jasne? addEventListener jest wywoływana na obiekcie DOM, więc this w kontekście wywołania tej metody wskazuje na ten obiekt właśnie – przycisk. Funkcja callback (czyli właściwie foo) nie jest wywoływana na obiekcie, nie mamy również strict mode, więc zgodnie z poznanymi zasadami jej this wskaże na obiekt window.
Pozostaje nam w takim razie jedna kwestia – jak wymusić na foo, żeby pokazywała jako this coś innego, to co chcemy?
Metody call i apply
To jest nasza odpowiedź. Obie powyższe metody pozwalają na wywoływanie funkcji z dowolnymi parametrami i dowolną wartością this. Oznacza to, że możemy wywołać funkcję w taki sposób, że this nie będzie ustalane przez JS-a, tylko przez nas! Różnica między nimi jest tylko taka, że w call parametry wypisujemy po kolei jak przy standardowym wywołaniu:
func.call(thisArg, param1, param2);
Natomiast w przypadku apply parametry funkcji są przekazywane w formie tablicy:
func.call(thisArg, [argsArray]);
W każdym razie obie nadadzą się idealnie, w sytuacji, gdy chcemy wprowadzić do danego kontekstu funkcji własne this. Mając nową wiedzę, możemy łatwo zmodyfikować nasz przykład.
function addEventListener(eventType, callback) {
const targetElement = this;
callback.call(targetElement, eventData);
}
Metoda call nadal będzie w stanie wywołać funkcję ukrytą pod callback, ale tym razem wartość this w takiej funkcji będzie równa targetElement… czyli tak naprawdę thisowi z kontekstu funkcji addEventListener! Koniec końców funkcja callback przy takim uruchomieniu będzie miała więc faktycznie ten sam this co kontekst funkcji addEventListener. Zatem w końcu, przekazana do nasłuchiwacza foo, pokaże nam, zgodnie z planem, jako this nasz button.
Jak widzisz, nie było to aż takie trudne, ale okazuje się, że bez znajomości tej zasady i wiedzy, że w ogóle można wymusić na kontekście funkcji własne this, zrozumienie, co tu się właściwie stało, nie byłoby możliwe.
Nie oczekujemy, że masz ten przykład zapamiętać. Prawdopodobnie podczas niniejszego kursu, nigdy nie będzie potrzeby skorzystania ze wspomnianych metod. Pokazaliśmy ten przykład głównie dlatego, żeby udowodnić Ci, że za JS-em nie stoi żadna magiczna siła. To wszystko z czegoś wynika. Tak naprawdę wystarczy, że wyniesiesz z tego jedno, iż funkcja callback uruchamia przez nasłuchiwacz domyślnie zawsze wskaże jako this ten element, na którym uruchomiona była sama metoda addEventListener.
Oczywiście nie tylko addEventListener może korzystać z metody .call. To zwykła metoda, sami też możemy jej użyć w naszym kodzie i jako this podstawić cokolwiek tylko chcemy.
Spójrz na ten przykład:
function foo() {
console.log(this);
}
foo.call({ bar: 'baz' });
Otrzymamy w konsoli:
Podsumowując: za pomocą metody call lub apply możemy wymusić dowolną wartość this w danym kontekście, nie zważając nawet, jaka byłaby domyślnie.
Hard binding
Metody call i apply pozwalają wymusić dowolną wartość tylko this przy konkretnym wywołaniu. Na stałe już nie. Istnieje jednak inna metoda, która jest w stanie to zrobić!
To metoda bind. Potrafi ona na podstawie dowolnej funkcji stworzyć nową, która po otrzymaniu na starcie założonego z góry this, zawsze będzie się go trzymać, nieważne, w którym miejscu w kodzie (call site) ją wywołamy. Brzmi nieźle? Jak najbardziej i działa równie prosto.
Spójrz tylko na poniższy przykład:
function foo(param) {
console.log(this, param);
}
const lockedFoo = foo.bind({ bar: 'baz' });
const obj = {
foo: lockedFoo
};
lockedFoo('Spam!');
obj.foo('Spam!');
I na efekt jego działania:
Nieważne, czy włączamy tę funkcję w kontekście globalnym, czy jako metodę obiektu obj, to this jest za każdym razem taki sam. Co więcej, wciąż da się przekazać jakieś parametry!
W jakiej sytuacji bind ma zastosowanie? Kiedy np. this ma ogromne znaczenie dla działania funkcji, ta jest wywoływana w wielu miejscach, a my chcemy mieć pewność co do jego wartości. Wbrew pozorom bind nie jest aż tak często używane, ale na pewno jest to metoda, którą warto znać.
Podsumujmy tę, ale i wcześniejszą zasadę. Za pomocą metody call lub apply możemy wymusić wartość dowolną this przy konkretnym wywołaniu funkcji, nie zważając nawet, jaka byłaby domyślnie. Metoda bind pozwala nam za to stworzyć nową funkcję na bazie już istniejącej, która na zawsze z domysłu będzie miała z góry założoną wartość this, nie zważając na miejsce wykonania.